查看原文
其他

兰小伟:Java中的异常传播(三)

益达 中生代架构 2019-05-15

架构师成长的好伙伴连接技术 接力价值



授权声明

本文经作者兰小伟授权发于中生代架构(ArchThink)


兰小伟:腼腆内秀的“80后Java码农”,艺名:益达,IT圈摸爬滚打5年6载,现于国美金融打杂谋生,业余著有拙作《Solr权威指南》上下册。爱学习乐分享,开源精神的拥趸。自知天资愚钝,故仍砥砺前行!


责编:大白

 第 4篇架构好文:10000字 | 15分钟阅读


前言

通过前面两篇的介绍,我想大家应该都了解了Java中的异常是如何工作的。为了让大家更深入的理解Java中的异常,这也是大家在面试大厂时,面试官最喜欢的提问方式,由浅入深,逐步挖掘你的理解深度。

作为一个有技术追求,自然不能满足于一知半解,故在写完前面两篇,我一直在思考该如何通透的把Java中异常的底层运行原理讲解清楚。我能想到的方式大概只有阅读编译后的字节码文件啦。OK,let's start!


为了方便讲解,这里我准备了一小段异常示例代码,如下所示:

~~~java

1public class ExceptionTest {
2    static int remainder(int dividend, int divisor) throws DivideByZeroException {
3        try {
4            return dividend % divisor;
5        } catch (ArithmeticException e) {
6            throw new DivideByZeroException();
7        }
8    }
9
10    public static void main(String[] args) throws DivideByZeroException {
11        int result = remainder(10,0);
12        System.out.println(result);
13    }
14}
15
16public class DivideByZeroException extends Exception {
17}

上述代码中,我们声明了一个remainder函数,它接收两个int类型参数,dividend参数表示被除数,divisor参数表示除数,函数里面返回dividend/divisor的计算结果,其实就是一个求余数的过程。我们都知道这里可能会抛出除以零的异常,这里我们捕获了ArithmeticException异常,然后将ArithmeticException这个运行时异常转化成DivideByZeroException检查性异常,最后抛出这个检查性异常。这里DivideByZeroException是我们自定义的异常,它继承自Exception,故它是一个检查性异常。


然后我们需要通过javac命令对这个ExceptionTest测试类进行编译以获取到它的class字节码文件,这里就不演示了,我想大家都会。有了字节码文件,下一步我们需要查看它的字节码内容,这里我们可以通过javap命令来查看,具体操作命令如下所示:  

~~~

1shell
2javap -c -v -l ExceptionTest.class

~~~

然后在命令行窗口里就会返回我们想要的字节码内容,如下图所示:

不过,这里我推荐你使用JBE(Java Bytecode Editor即Java字节码编辑器)工具,因为它使用起来更方便。JBE的下载地址如下所示:

~~~

1http://www.cs.ioc.ee/~ando/jbe/

~~~

打开JBE工具之后,点击顶栏菜单栏里的File-->Open class file,然后选择你的class文件即可查阅该**class**文件的具体字节码内容。下图是我们上述示例中的ExceptionTest类的字节码内容:

我们重点关注Bytecode这个选项卡里的内容,在理解每一行指令的含义之前,我们需要先了解JVM的内部组成结构,下图是基于Java SE 7规范的典型JVM的核心内部组件:

自己看书照猫画虎,希望大家不要嫌弃我画得丑,就将就着看吧

下面对图中出现的一些专业术语进行一些解释说明:

+ Stack:最左边的Stack表示线程栈,跟算法数据结构里我们经常说的堆栈不是一个东西。这块的Stack(即也有的中文书籍里将其翻译为虚拟机栈)主要被JVM内部所使用,Java编码人员是无法操控这块内存区域的。它是线程私有的内存区域,并且同线程共生死,即线程消亡了,那么它也会随之消亡。


+ Process Counter:也有的书里叫做Program Counter,都是程序计数器的意思。当然也有的英文书籍里会简称为PC,其实就是单词首字母缩写形式,乍一看,容易让人懵逼。PC 存储了指向下一条要被执行的指令内存地址,该内存地址实际上就是方法区(Method Area)里存储的一个内存地址。JVM使用PC来跟踪指令执行的位置。至于什么程序计数器,更详细的解释请移步到百度百科去学习了解,传送门:[程序计数器]

1(https://baike.baidu.com/item/%E7%A8%8B%E5%BA%8F%E8%AE%A1%E6%95%B0%E5%99%A8/3219536?fr=aladdin)

+ Frame:完整的叫法是Stack Frame。栈是被划分为一格一格的,每一格就是一个Stack Frame,中文一般翻译为栈帧。


+ Local Variable:也有的英文书籍里叫做Local Variable Table,翻译过来就是本地变量表,也有的中文书籍翻译为局部变量表,你懂得它们是一个意思就行。本地变量表是一组变量值的存储空间,它被分成一格一格的小块,就像数组一样,而且每一个格子都有一个索引下标。每一格英文术语叫Slot,Slot翻译过来就是槽位,太难听了,所以我一般理解为一个格子。每一个格子可以存储方法,参数,以及方法内部定义的局部变量。


+ Operand Stack:翻译过来就是操作数栈,与本地变量表类似,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引下标来访问,而是通过标准的栈操作即压栈和出栈来访问的。即先把操作指令和操作数压栈(Push),稍后真正需要进行计算时,再弹出栈(即Pop)。虚拟机在操作数栈中存储数据的方式与本地变量表类似,都支持int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值都是在压入到操作数栈之前,先转换成int后再压栈。不同于程序计数器,JVM中没有寄存器,程序计数器也无法被程序指令直接访问。Java虚拟机的指令是从操作数栈中而不是从寄存器中取得操作数的,因此它的运行方式是基于栈的而不是基于寄存器的。虽然指令也可以从其他地方取得操作数,比如从字节码流中跟随在操作码(代表指令的字节)之后的字节中或从常量池中,但是主要还是从操作数栈中获得操作数。


+ Native Stack:即本地方法栈,并非所有的 JVM实现都支持本地方法栈,如果该JVM实现支持本地方法栈,那么在创建线程时都会为其创建一个本地方法栈,如果有了本地方法栈,那么你就可以直接在C代码里调用Java中的方法。这种Native方法调用Java方法行为一般会将其从本地方法栈转移到Java栈上,即上图中跟Native Stack相邻的左边的那个Stack。Java中的线程栈都是由多个栈帧组成,每个方法执行前JVM都会为其创建一个栈帧,如果栈空间已满,无法继续创建新的栈帧,那么JVM将会抛出StackOverflowError异常,这时候你可以通过-xss参数来调整栈空间大小以缓解栈空间不足的问题。


+ Frame:即栈帧,每个方法执行之前,JVM都会为其创建一个栈帧,并将其压入栈顶。如果方法正常执行并返回或者方法运行过程中抛出了未被捕获的异常,那么该方法的当前栈帧将会被弹出栈。栈帧被弹出,即意味着方法执行的生命周期结束,该方法的执行被终止。而如果此时该方法内部抛出的是运行时异常,那么此时该异常将会在该方法的栈帧被弹出栈之后被传播到新的栈帧(方法调用者的栈帧)中,然后依据我们之前介绍过的异常传播机制,异常就会一直往栈底传播,直到main线程,若main线程也没有处理该异常,也会导致main方法终止,即main线程消亡。

当发生异常时,JVM是如何判断当前栈帧该弹出栈的呢?其实如果你在方法里定义了try-catch或try-finally代码块,就等价于你定义了一个异常处理器,JVM就认为你定义了异常处理器,那么你就有处理异常的能力,JVM就不会将当前方法的栈帧弹出栈,也就是说JVM就不会强行将当前方法kill掉。

但前提是你没有在catch块里重新throw一个运行时异常,就好比你把一个任务接过来,你不处理直接扔给别人,这不是在处理“任务”,而是在“甩锅”(这里说的甩锅就是形象的表示将异常抛给方法的上层调用者的行为)。这种甩锅行为,对于JVM而言,还是会认为你并没有处理异常的能力,依然会将当前方法的栈帧弹出栈。

此外,你通过try-catch定义的异常处理器,本质是存储在异常表(英文术语叫做Exception Table)里,异常表里主要存储了start_pc,end_pc,handler_pc,catch_type,其中start_pc和end_pc确定了异常处理器覆盖的代码块区域,handler_pc表示异常处理器实际需要执行的指令在程序计数器里的内存地址,catch_type记录着实际需要处理的异常的类型。JVM判断方法方法有没有处理该异常的能力,就是通过扫描Exception Table来实现的,如果找到该异常的相关信息,则认为当前方法定义了异常处理器即拥有处理当前异常的能力,那么JVM就不会将其栈帧弹出栈,否则该方法处于栈顶的栈帧将会被强制弹出以终结该方法的执行过程。


+ Code Cache:翻译过来就是代码缓冲区,想要理解Code Cache,你需要稍微了解一下JIT编译器。

>当一个Java程序运行时,代码是以分层的方式执行的。在第一层,Java使用所谓的客户端编译器(C1编译器模式)来编译代码。上一层剖析出来的数据将用于服务器编译器的第二层(C2编译器模式),它以一种优化和高性能的方式编译该代码。在Java 7中,分层编译并没有被启用,但是在Java 8中它默认是启用的。JIT-编译器将编译后的代码存储在一个名称为代码缓存(Code Cache)的区域中。

如果它的大小超过一定的treshold(阈值),那么这个代码缓冲区域就会被刷新。内存大小和其他一些参数,比如代码缓冲区flush的阈值,可以通过JVM参数进行设置。当代码缓冲区被填满时,就会导致一些很奇怪的问题,这个问题一般很难解决。在某种程度上,这个问题的表现就是我们运行在Apache Tomcat上的web应用程序在几天后,响应会越来越慢。这种响应缓慢问题我们可以通过一些监控工具查看到,一般都是由于系统负载问题导致。然而,在数据库层(在MySQL慢速查询日志中)或应用程序层,我们看不到任何显式的性能问题的提示信息,只是随着时间的推移,应用程序变得越来越慢。

在使用jconsole(一个JDK自带的工具)工具后,我们会发现,在这个工具的内存部分中,代码缓存区域部分在不断增加。在Sun Oracle 1.7u79中,代码缓存区域的默认最大大小为48 MB(64位版本下)。一旦代码缓存区域在几天后被填满之后,就开始性能下降。当代码缓存区被填满后,JDK 7中存在一个很重大问题:即使在代码缓冲区刷新之后,代码缓冲区的占用率下降到几乎只有一半时,强制刷新可能会导致编译器线程的CPU使用率飙升,从而导致整体性能下降。关于如何解决JDK 7的这个问题,有两个建议:关闭代码缓存区的刷新功能,或者增加代码缓存区的大小,但是我推荐你使用第二种方式,至于如何配置JVM参数增加代码缓冲区大小,请自行网络搜索。


+ Method Area:即方法区,所有线程共享一个方法区,因此访问方法区内的共享数据时需要注意线程安全问题。方法区里主要存储了以下信息:

1. 类加载器的引用

2. 运行时常量:运行时常量又细分为字符串常量、数字常量、方法相关数据、属性相关常量、动态调用(即上图中的Invoke Dynamic)字节码

3. 方法代码:方法代码(即上图中的Method Code)里主要存储了每个方法的字节码、操作数栈的大小、本地变量表的大小、本地变量表、异常处理器、异常表、异常处理器覆盖的代码块区域即start_pc和end_pc的值、异常处理器的程序计数器的偏移量、被捕获的异常在常量池里对应的下标。

4. Invoke Dynamic: 即动态调用,我知道你肯定会问:何为动态调用?Invoke Dynamic其实对应的是JVM中的invokedynamic指令,跟invokedynamic指令类似的还有:

+ invokevirtual——对实例方法的标准分派

+ invokestatic——用于分派静态方法

+ invokeinterface——用于通过接口进行方法调用的分派

+ invokespecial——当需要进行非虚(也就是"精确")分派时会用到

invokedynamic指令定义于JSR-292草案中,它主要用来协助实现JVM实现动态语言。我想大家应该都听说过"Duck Typing"(即鸭子类型),意思就是如果它走起来像鸭子,叫起来也像鸭子,那我们就可以认为它就是一只鸭子,而不用管它现在到底是什么。这种特性一般多出现于类似JavaScript、Python、Ruby、Groovy这类的动态语言中,JVM有意向动态语言靠拢,故引入了invokedynamic指令。比如像Groovy中,你可以动态将一个Method在运行时附加到某个类上,最后你可以通过该类执行动态附加上去的方法,就仿佛该方法事先在类里已经定义了一样。


+Heap:即JVM的垃圾回收器管辖的主要区域,堆与方法区都是所有线程共享的,堆存在的意义就是为了存储对象的实例,大部分对象都是直接在堆上直接分配内存的,当然也有部分是在栈或堆外内存上进行分配,这些特殊情况另说。JVM规范中规定,堆内存需要在逻辑上是连续的(物理上不要求必须是连续的)。


OK,基于上述JVM体系结构的了解,我们再来看看之前我们生成的字节码:

~~~

10    bipush 10
22    iconst_0
33    invokestatic com/yida/test/ExceptionTest/remainder(II)I
46    istore_1
57    getstatic java/lang/System/out Ljava/io/PrintStream;
610  iload_1
711  invokevirtual java/io/PrintStream/println(I)V
814  return

~~~

这里再简单提一下JVM的指令集,即上面字节码中你看到的bipush、iconst_0之类的指令。类似这样的指令,JVM规范中定义了很多,没必要一个个死记硬背,其实很多都是能望文生义的。比如bipush这里的第一个字母b表示byte类型,同理i表示int或Integer,l表示long或Long,s表示short,f表示float,c表示char,d表示double,a表示对象引用类型。嗯?怎么没有boolean?boolean在JVM里统一按照int来存储了,所以boolean类型的true在JVM里实际存储的是int类型的1,具体看下图:

const即常量的意思,后面的零表示常量值0,那么iconst_0就表示将常量0从常量池压入操作数栈,bipush即表示将一个byte类型的实际是个int类型(因为我们代码里实际定义的是int dividend)的常量10压入操作数栈。这里将int类型转成byte类型存储是为了节省空间,这也是为什么这里选择使用bipush,而不是iconst或idc的原因。JVM关于int类型的存储有一个优化规则:

>当int取值范围为-1~5,则采用iconst_<n>指令,取值范围为-128~127,则采用bipush指令,若取值范围为-32768~32767,则采用sipush指令,若取值范围为-2147483648~2147483647,则采用 ldc 指令。当int为-1时,JVM有个专门的iconst_m1的指令,这里的m即minus(翻译过来就是负数的意思),这样去记忆是不是觉得这些指令也没有特别的难记。

包含load的系列指令都表示将本地变量表指定位置的变量加载到栈顶,store则刚好是load系列指令的反操作,即将栈顶数据存储到本地变量表的指定位置。关于方法调用相关的JVM指令有以下几种:

+ invokevirtual:调用实例的方法,属于虚方法分发

+ invokestatic:这个毫无疑问就是调用类的静态方法,即被static关键字修饰的方法。

+ invokeinterface:调用接口的方法,属于在运行时决定该调用具体哪个方法

+ invokespecial:调用特殊实例的方法,比如调用父类的方法,调用实例的初始化方法invokedynamic

+ invokedynamic:即由用户主动引导,在程序运行时动态解析并确定出调用点限定符所引用方法

下面再说说上述字节码中JVM指令左侧的数字表示什么含义,比如bipush前面有个数字零,iconst_0左边有个数字2。而且貌似第一列的每个数字之间的间隔也没有什么规律可循,我想作为一个有技术追求的码农,应该都会有这个求知欲。

其实我们通过Java语法编写的方法在JVM底层都是通过指令集表示的,而指令集是有执行顺序的,当然并不一定我们写的某一行代码就对应一条指令,有可能一条语句对应了多条JVM指令。而我们通过javap命令或者JBE工具看到的字节码中显示的指令名称,比如bipush、iconst_<n>这些只是这些JVM指令的显示名称,说白了,这些名称是用来给编程人员看的,而JVM实际存储的却是指令的操作码(英文术语叫opcode),所谓操作码就是JVM指令的一个唯一代码,就好比每个公民都会有一个唯一的身份证号来确定你就是你。关于JVM指令集中每个指令对应的操作码请查阅如下链接提供的对照表:  

1[JVM指令的操作码对照表](http://homepages.inf.ed.ac.uk/kwxm/JVM/codeByNo.html)

也就是说我们在方法里写的代码最终会按照执行顺序变成了一串操作码序列,而决定这些操作码执行顺序的原因就是因为ByteCode Array(翻译过来就是字节码数组)的存在。操作码序列会按执行顺序插入到字节码数组里。但是为什么bipush和iconst_0的操作码之间会隔一个1呢,这是因为bipush后面还有一个操作参数10,而操作参数也是需要占用字节码数组的空间的,即字节码数组中操作码=1的地方实际存储的是数字10的十六进制形式。

操作码=3处表示开始调用静态方法remainder,这里的(II)I表示(int,int):int,意思就是该方法第一个参数是int类型,而第二个参数类型也是int,括弧外面紧挨着的那个字母I表示返回值类型也是int类型。也有可能是DD,DI,AD等形式,你懂的。

执行完remainder这个静态方法后,通过istore_1指令将remainder方法执行完后压入栈顶的数据存储到本地变量表的索引(索引位置从零开始计算)为1的位置。

然后通过getstatic指令加载PrintStream类,然后通过iload_1指令将刚刚存入本地变量表的索引位置=1的数据再加载出来压到栈顶,然后通过invokevirtual指令执行PrintStream类的println方法,这里println(I)V里的大写的字母V表示返回值类型void。最后执行return指令结束main函数的执行过程。

紧接着我们再来分析remainder方法里的具体指令执行过程,以下是remainder方法相关的字节码指令:

10    iload_0
21    iload_1
32    irem
43    ireturn
54    astore_2
65    new com/yida/test/DivideByZeroException
78    dup
89    invokespecial com/yida/test/DivideByZeroException/<init>()V
912  athrow

0-->从本地变量表中将索引位置=0处的变量值加载出来并压入当前栈顶,即变量表里的数字10


1-->从本地变量表中将索引位置=1处的变量值加载出来并压入当前栈顶,即变量表里的数字0


2-->irem指令就是用来求余数的


3-->执行irem指令,尝试返回计算结果,即执行到Java代码中的这句"return dividend % divisor;"的return关键字这里


4-->astore_2指令表示将当前栈顶的对象引用写入本地变量表里索引位置=2处,通过查阅生成的字节码中的本地变量表部分内容,我们可以知道,这里的对象引用其实就是我们在catch块里声明的ArithmeticException异常,如下所示,请看Slot(槽位)等于2处的变量值:

1LocalVariableTable:
2        Start  Length  Slot  Name   Signature
3            5       8     2     e   Ljava/lang/ArithmeticException;
4            0      13     0 dividend   I
5            0      13     1 divisor   I

也就是说我们在代码中写的catch(XXXException e)其实就等价于astore_<n>指令。同时还会往Exception Table中插入一条记录,如下图所示:

5-->通过new指令构建DivideByZeroException对象的实例,构建该对象实例时实际调用的是父类Throwable的这个构造函数:

~~~

1public Throwable(String message, Throwable cause) {
2        fillInStackTrace();
3        detailMessage = message;
4        this.cause = cause;
5    }

~~~

>这里有个疑问:new指令与下一个dup指令之间的编号为何相差2,这两个位置导致被谁占用了?通过查阅Oracle官网提供的JVM规范文档,我们可以得知,这两个位置存储的是indexbyte1和indexbyte2,它们是两个无符号的数字,这两个数字进行 (indexbyte1 << 8) | indexbyte2的位运算得到一个index值,这个index值直接指向常量池中待创建对象的Class信息。注意:new指令只是发起一个对象创建指令,并不会等待对象构造以及初始化完毕,即new指令并不能保证返回的对象不为null。此外,new指令执行完毕后,会将对象引用压入操作数栈的栈顶。


8-->dup指令会将上一步中new指令压入栈顶的对象引用弹出栈复制一份,复制完之后再把它放回栈顶。


9-->invokespecial指令其实就是调用DivideByZeroException的构造函数,完成对DivideByZeroException对象的初始化工作,因为构造函数里需要this对象引用,这也是为什么我们上一步为什么要将对象引用通过dup指令复制一份的原因,因为invokespecial指令需要将其弹出栈传入到DivideByZeroException的构造函数中,其实这也解释了Java中this关键字的实现原理。然后又是从编号9跳到编号12,上面已经解释过了,你懂的。


12-->athrow指令会弹出当前栈顶的DivideByZeroException对象引用,然后去Exception Table查找是否有该异常的处理器,即handler_pc部分,如果找不到异常处理器,那么当前栈帧就会被清除,意味着当前方法执行终止,然后按照异常传播机制逐层往上传播,依次类推,直至main函数。如果找到异常处理器,那么该异常对象的引用会重新被压入操作数栈,使得方法能继续执行下去。

最后结束语

至此,关于Java里的异常传播机制就介绍完毕了,今天这篇主要是从JVM字节码层面去深入了解,当Java代码抛出异常时,JVM底层导致是如何工作的?在Java里,我们编写的代码其实最终都会被转换成JVM指令,理解这些JVM指令,并了解它的内部工作原理,有助于你写出更短小精悍却性能强劲的代码!

推荐阅读


兰小伟:Java中的异常传播(二)

兰小伟:Java中的异常传播(一)

米么金服首席架构师曲健:微服务接口兼容性升级之序列化



点击阅读原文查看兰小伟的技术博客

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存